SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
4 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
20.0 KB · 144 lines tsx
Raw Blame History
1"use client";2import { useState } from "react";3import Link from "next/link";4import type { ValuationRow } from "@/lib/types";5import type { ToveliEstimate } from "@/lib/estimate";6import { fmtPrice, fmtPct, fmtDate, fmtInt, fmtArea } from "@/lib/format";7import { Tabs, Notice, Stat } from "@/components/ui";8import { PriceHistoryChart } from "@/components/price-chart";9import { MapView } from "@/components/map-view";10import { MortgageWidget } from "@/components/mortgage-widget";11import { EstimateBreakdown } from "@/components/estimate-breakdown";12import type { Insight, MarketPosition } from "@/lib/insights";13import Link2 from "next/link";1415type L = { uid: string; market: string; price: number | null; description: string | null; features: string[]; details: Record<string, unknown>; lat: number | null; lng: number | null; category: string; city: string | null; address: string | null; area_sqft: number | null; year_built: number | null; bedrooms: number | null; bathrooms: number | null; direct?: boolean };16type Fin = { municipalTax: number | null; schoolTax: number | null; assessment: number | null; condoFees: number | null };17type Budget = { rent: number; electricity: number | null; heatingIncluded: boolean; electricityIncluded: boolean; internet: number; total: number; notes: string[] } | null;18type Score = { walk: number | null; transit: number | null; bike: number | null; calm: number | null; services: number | null; global: number | null; version: string; computed_at: string } | null;19type Sale = { id: string; date: string; amount: number; street: string; city: string; property_type: string | null; year_built: number | null; floor_area: number | null };20type Prop = { id: string; role_value: number | null; role_land: number | null; role_building: number | null; key_kind: string; dedup_confidence: number | null; n_listings: number } | null;2122const SKIP_KEYS = new Set(["pieces", "img_audited", "transaction", "prix_pi2", "prix_m2", "inclusions", "appliances", "parking"]);2324export function ListingTabs({ listing: l, valuation, live, priceEvents, score, sales, property, insights = [], position, financials, budget }: { listing: L; valuation: ValuationRow | null; live: ToveliEstimate | null; priceEvents: { ts: string; price: number }[]; score: Score; sales: Sale[]; property: Prop; insights?: Insight[]; position?: MarketPosition; financials?: Fin; budget?: Budget }) {25  const rent = l.market === "rent";26  const items = [{ id: "apercu", label: "Aperçu" }, { id: "prix", label: rent ? "Loyer et marché" : "Prix et marché" }, ...(!rent && live ? [{ id: "comparables", label: "Comparables" }] : []), ...(!rent ? [{ id: "budget", label: "Budget" }] : []), { id: "quartier", label: "Quartier" }];27  const [tab, setTab] = useState("apercu");28  const detailEntries = Object.entries(l.details).filter(([k, v]) => !SKIP_KEYS.has(k) && v != null && typeof v !== "object" && String(v).trim() !== "").slice(0, 40);29  const pieces = Array.isArray(l.details.pieces) ? (l.details.pieces as { nom?: string; niveau?: string; dimensions?: string; revetement?: string }[]) : [];30  const est = live?.result.estimate ?? valuation?.estimate ?? null;31  const method = valuation?.method_detail ? (JSON.parse(valuation.method_detail) as Record<string, number | string | null>) : null;32  return (33    <div className="mt-6">34      <Tabs items={items} current={tab} onChange={setTab} />35      <div className="pt-5 space-y-6">36        {tab === "apercu" && (<>37          {insights.length > 0 && <section className="tv-card p-4"><h2 className="font-bold text-lg mb-2">En bref</h2><ul className="space-y-1.5 text-sm">{insights.map((i) => <li key={i.text} className="flex gap-2"><span className={`mt-1.5 w-2 h-2 rounded-full shrink-0 ${i.tone === "good" ? "bg-good" : i.tone === "warn" ? "bg-warn" : i.tone === "info" ? "bg-info" : "bg-ink-3"}`} aria-hidden /><span className="text-ink-2">{i.text}</span></li>)}</ul><p className="text-[11px] text-ink-3 mt-2">Constats calculés à partir des données de l'annonce et du marché Toveli, sans interprétation éditoriale.</p></section>}38          {budget && <section className="tv-card p-4"><h2 className="font-bold text-lg mb-2">Budget mensuel estimé</h2><div className="grid grid-cols-2 sm:grid-cols-4 gap-3"><Stat label="Loyer" value={fmtPrice(budget.rent)} /><Stat label="Électricité et chauffage" value={budget.electricityIncluded ? "Inclus" : fmtPrice(budget.electricity)} sub={budget.electricityIncluded ? "électricité incluse" : budget.heatingIncluded ? "chauffage inclus, électricité estimée" : "estimation, chauffage électrique"} /><Stat label="Internet" value={fmtPrice(budget.internet)} sub="forfait moyen" /><Stat label="Total indicatif" value={fmtPrice(budget.total)} tone="info" /></div><ul className="text-[11px] text-ink-3 mt-2 list-disc pl-4">{budget.notes.map((n) => <li key={n}>{n}</li>)}</ul></section>}39          <section><h2 className="font-bold text-lg mb-2">Description</h2>{l.description ? <p className="text-sm text-ink-2 whitespace-pre-line leading-relaxed">{l.description}</p> : <p className="text-sm text-ink-3">Aucune description fournie par la source.</p>}</section>40          {l.features.length > 0 && <section><h2 className="font-bold text-lg mb-2">{rent ? "Inclusions et commodités" : "Caractéristiques"}</h2><ul className="grid sm:grid-cols-2 gap-1.5 text-sm text-ink-2">{l.features.slice(0, 40).map((f) => <li key={f} className="flex gap-2"><span className="text-brand">•</span>{f}</li>)}</ul></section>}41          {detailEntries.length > 0 && <section><h2 className="font-bold text-lg mb-2">Détails pratiques</h2><dl className="grid sm:grid-cols-2 gap-x-6 gap-y-1.5 text-sm">{detailEntries.map(([k, v]) => <div key={k} className="flex justify-between gap-3 border-b border-line py-1"><dt className="text-ink-3 capitalize">{k.replace(/_/g, " ")}</dt><dd className="text-right text-ink-2 truncate max-w-[60%]" title={String(v)}>{String(v)}</dd></div>)}</dl></section>}42          {pieces.length > 0 && <section><h2 className="font-bold text-lg mb-2">Pièces</h2><div className="overflow-x-auto"><table className="w-full text-sm"><thead><tr className="text-left text-ink-3 text-xs"><th className="py-1 pr-3 font-medium">Pièce</th><th className="py-1 pr-3 font-medium">Niveau</th><th className="py-1 pr-3 font-medium">Dimensions</th><th className="py-1 font-medium">Revêtement</th></tr></thead><tbody>{pieces.slice(0, 30).map((p, i) => <tr key={i} className="border-t border-line"><td className="py-1 pr-3">{p.nom}</td><td className="py-1 pr-3 text-ink-2">{p.niveau}</td><td className="py-1 pr-3 text-ink-2">{p.dimensions}</td><td className="py-1 text-ink-2">{p.revetement}</td></tr>)}</tbody></table></div></section>}43        </>)}4445        {tab === "prix" && (<>46          {!rent && (live || valuation) ? (47            <section>48              <h2 className="font-bold text-lg mb-3">Valeur estimée et écart avec le prix demandé</h2>49              <div className="grid sm:grid-cols-3 gap-3">50                <Stat label="Estimation Toveli" value={fmtPrice(est)} sub={`${fmtPrice(live?.result.low ?? valuation?.low)} – ${fmtPrice(live?.result.high ?? valuation?.high)}`} />51                <Stat label="Prix demandé" value={fmtPrice(l.price)} sub={est && l.price ? `écart ${fmtPct((l.price - est) / est)} vs estimation` : undefined} tone={est && l.price ? (l.price > est * 1.05 ? "warn" : l.price < est * 0.95 ? "good" : "info") : undefined} />52                <Stat label="Confiance" value={live ? `${live.result.confidenceLevel} · ${live.result.confidencePct} %` : valuation?.confidence ?? "—"} sub={live ? `${live.result.nCompsUsed} comparables${live.result.compsDispersionPct != null ? ` · dispersion ${live.result.compsDispersionPct} %` : ""}` : valuation?.evaluated_at ? `évaluée le ${fmtDate(valuation.evaluated_at)}` : undefined} />53              </div>54              {live && <div className="mt-4"><EstimateBreakdown e={live} price={l.price} /></div>}55              {!live && method && (56                <dl className="mt-4 grid sm:grid-cols-2 gap-2 text-sm">57                  {[["Modèle hédonique", method.model_est], ["Comparables ajustés", method.comps_est], ["Méthode du coût", method.cost_est], ["Rôle indexé (ratios IAAO)", method.role_est], ["Ensemble (médiane)", method.ens_est], ["Valeur au rôle", method.valeur_role]].filter(([, v]) => v).map(([k, v]) => <div key={String(k)} className="flex justify-between border-b border-line py-1"><dt className="text-ink-3">{k}</dt><dd className="tabular-nums">{fmtPrice(Number(v))}</dd></div>)}58                </dl>59              )}60              <div className="mt-3"><Notice>Méthode : {live?.method.name ?? "hybride Toveli (modèle hédonique + comparables ajustés)"} · version {live?.method.version ?? valuation?.model_version} · {live ? `calculée aujourd'hui` : `évaluée le ${fmtDate(valuation?.evaluated_at)}`}. {live?.method.disclaimer ?? "Estimation statistique indicative ; ne remplace pas une évaluation agréée (OEAQ)."}</Notice></div>61              {live && live.method.limits.length > 0 && <ul className="mt-2 text-xs text-ink-3 list-disc pl-5 space-y-0.5">{live.method.limits.map((x) => <li key={x}>{x}</li>)}</ul>}62            </section>63          ) : rent && valuation ? (64            <section>65              <h2 className="font-bold text-lg mb-3">Loyer demandé et loyer de référence</h2>66              <div className="grid sm:grid-cols-3 gap-3">67                <Stat label="Loyer de référence (modèle)" value={fmtPrice(valuation.estimate, { rent: true })} sub={`${fmtPrice(valuation.low)} – ${fmtPrice(valuation.high)}`} />68                <Stat label="Loyer demandé" value={fmtPrice(l.price, { rent: true })} sub={valuation.estimate && l.price ? `écart ${fmtPct((l.price - valuation.estimate) / valuation.estimate)}` : undefined} tone={valuation.verdict === "sous" ? "good" : valuation.verdict === "sur" ? "warn" : "info"} />69                <Stat label="Confiance" value={valuation.confidence ?? "—"} sub={method?.comps ? `${method.comps} logements comparables · ${method.segment ?? ""}` : undefined} />70              </div>71              <div className="mt-3"><Notice>Modèle de loyer de référence Toveli ({valuation.model_version}) : médianes par segment (ville/quartier × chambres), ajustements multiplicatifs, voisins géographiques. Indicatif seulement.</Notice></div>72            </section>73          ) : <Notice>Aucune estimation disponible pour cette annonce ({l.category === "terrain" || l.category === "commercial" ? "catégorie non couverte par le modèle" : "données insuffisantes"}).</Notice>}7475          {position?.metric && (76            <section>77              <h2 className="font-bold text-lg mb-2">Position dans le marché {position.metric.scope === "province" ? "québécois" : `· ${position.metric.scope_value}`}</h2>78              <div className="tv-card p-4">79                <div className="relative h-2 rounded-full bg-surface-2 mt-5 mb-6">80                  {[["P25", position.metric.p25_price], ["Médiane", position.metric.median_price], ["P75", position.metric.p75_price]].map(([k, v], i) => <span key={String(k)} className="absolute -bottom-5 text-[10px] text-ink-3 -translate-x-1/2" style={{ left: `${[25, 50, 75][i]}%` }}>{k} {fmtPrice(v as number, { compact: true, rent })}</span>)}81                  {position.pricePercentile != null && <span className="absolute -top-5 text-[11px] font-semibold text-brand-dark -translate-x-1/2 whitespace-nowrap" style={{ left: `${Math.min(97, Math.max(3, position.pricePercentile))}%` }}>Cette annonce · {position.pricePercentile}e centile<span className="block mx-auto mt-0.5 w-2.5 h-2.5 rounded-full bg-brand" /></span>}82                </div>83                <p className="text-xs text-ink-2">{fmtInt(position.metric.n)} {l.category === "logement" ? "logements" : `${l.category}s`} {rent ? "à louer" : "à vendre"} comparés · {position.metric.new_30d ?? 0} nouvelles en 30 jours · {position.metric.median_days != null ? `${position.metric.median_days} jours médians en ligne` : ""}{position.ppsfVsMedian != null ? ` · prix/pi² ${fmtPct(position.ppsfVsMedian)} vs médiane` : ""}.</p>84                {l.city && <Link2 href={`/marche?ville=${encodeURIComponent(l.city)}&marche=${rent ? "location" : "achat"}`} className="text-sm font-semibold text-brand-dark hover:underline">Toutes les données de {l.city} →</Link2>}85              </div>86            </section>87          )}88          {financials && (financials.municipalTax || financials.schoolTax || financials.assessment || financials.condoFees) && (89            <section><h2 className="font-bold text-lg mb-2">Taxes, évaluation et charges</h2><div className="grid grid-cols-2 sm:grid-cols-4 gap-3">{financials.assessment ? <Stat label="Évaluation municipale" value={fmtPrice(financials.assessment)} sub={l.price && financials.assessment ? `prix = ${(l.price / financials.assessment).toFixed(2)} × évaluation` : undefined} /> : null}{financials.municipalTax ? <Stat label="Taxes municipales" value={fmtPrice(financials.municipalTax)} sub="par année" /> : null}{financials.schoolTax ? <Stat label="Taxes scolaires" value={fmtPrice(financials.schoolTax)} sub="par année" /> : null}{financials.condoFees ? <Stat label="Frais de copropriété" value={fmtPrice(financials.condoFees)} sub="par mois" /> : null}</div><p className="text-xs text-ink-3 mt-1">Montants déclarés par la source ou issus du rôle d'évaluation ; vérifiez auprès de la municipalité.</p></section>90          )}91          <section>92            <h2 className="font-bold text-lg mb-2">Historique du prix demandé</h2>93            {priceEvents.length >= 2 ? <PriceHistoryChart events={priceEvents} rent={rent} /> : <p className="text-sm text-ink-3">Aucun changement de prix observé depuis la première collecte{priceEvents[0] ? ` (${fmtDate(priceEvents[0].ts)})` : ""}.</p>}94            {priceEvents.length >= 2 && <ol className="mt-2 text-xs text-ink-2 space-y-0.5">{priceEvents.slice(-6).reverse().map((e, i, a) => { const prev = a[i + 1]; return <li key={e.ts}>{fmtDate(e.ts)} · {fmtPrice(e.price, { rent })}{prev ? <span className={e.price < prev.price ? "text-good" : "text-warn"}> ({fmtPct((e.price - prev.price) / prev.price)})</span> : " · première observation"}</li>; })}</ol>}95          </section>9697          {property?.role_value != null && (98            <section>99              <h2 className="font-bold text-lg mb-2">Rôle d'évaluation municipal</h2>100              <div className="grid sm:grid-cols-3 gap-3"><Stat label="Valeur au rôle" value={fmtPrice(property.role_value)} /><Stat label="Terrain" value={fmtPrice(property.role_land)} /><Stat label="Bâtiment" value={fmtPrice(property.role_building)} /></div>101              <p className="mt-2 text-xs text-ink-3">Source : rôle d'évaluation foncière (MAMH, Données Québec, licence ouverte). La valeur au rôle reflète les conditions du marché à une date de référence antérieure, pas la valeur actuelle.</p>102            </section>103          )}104          {l.city && <p className="text-sm"><Link href={`/marche?ville=${encodeURIComponent(l.city)}&marche=${rent ? "location" : "achat"}`} className="font-semibold text-brand-dark hover:underline">Voir le marché de {l.city} →</Link></p>}105        </>)}106107        {tab === "comparables" && live && (108          <section>109            <h2 className="font-bold text-lg mb-1">Ventes comparables ajustées</h2>110            <p className="text-sm text-ink-2 mb-3">{live.result.nCompsUsed} ventes retenues parmi les candidates autour de la propriété, pondérées par distance, récence et similarité de superficie. Chaque ajustement est affiché en dollars.</p>111            <div className="overflow-x-auto tv-card">112              <table className="w-full text-sm">113                <thead className="text-xs text-ink-3 text-left"><tr><th className="p-2 font-medium">Vente</th><th className="p-2 font-medium">Date</th><th className="p-2 font-medium text-right">Prix</th><th className="p-2 font-medium text-right">Marché</th><th className="p-2 font-medium text-right">Superficie</th><th className="p-2 font-medium text-right">Âge</th><th className="p-2 font-medium text-right">Ajusté</th><th className="p-2 font-medium text-right">Dist.</th><th className="p-2 font-medium text-right">Poids</th></tr></thead>114                <tbody>{live.result.comps.map((c) => <tr key={c.id} className="border-t border-line"><td className="p-2"><span className="font-medium">{c.street ?? "Adresse non publiée"}</span><br /><span className="text-xs text-ink-3">{c.city} · {c.propertyType ?? "type n/d"}{c.floorArea ? ` · ${Math.round(c.floorArea)} m²` : ""}{c.yearBuilt ? ` · ${c.yearBuilt}` : ""}</span></td><td className="p-2 text-ink-2">{fmtDate(c.date)}</td><td className="p-2 text-right tabular-nums">{fmtPrice(c.amount)}</td><td className={`p-2 text-right tabular-nums ${c.adjTime >= 0 ? "text-good" : "text-warn"}`}>{c.adjTime >= 0 ? "+" : ""}{fmtPrice(c.adjTime)}</td><td className={`p-2 text-right tabular-nums ${c.adjArea >= 0 ? "text-good" : "text-warn"}`}>{c.adjArea >= 0 ? "+" : ""}{fmtPrice(c.adjArea)}</td><td className={`p-2 text-right tabular-nums ${c.adjAge >= 0 ? "text-good" : "text-warn"}`}>{c.adjAge >= 0 ? "+" : ""}{fmtPrice(c.adjAge)}</td><td className="p-2 text-right tabular-nums font-semibold">{fmtPrice(c.adjustedPrice)}</td><td className="p-2 text-right text-ink-2">{c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`}</td><td className="p-2 text-right text-ink-2">{(c.weight * 100).toFixed(0)} %</td></tr>)}</tbody>115              </table>116            </div>117            <p className="mt-2 text-xs text-ink-3">Ajustements : marché = indice mensuel par type ; superficie = 50 % du $/m² du comparable (± 25 % max) ; âge = 0,5 %/an (± 10 % max). Médiane pondérée des prix ajustés → estimation « comparables ». Source des ventes : base de transactions Toveli (ventes ≥ 50 000 $).</p>118          </section>119        )}120121        {tab === "budget" && !rent && l.price && (122          <section><h2 className="font-bold text-lg mb-3">Budget et financement</h2><MortgageWidget price={l.price} city={l.city} compact taxes={financials?.municipalTax ?? undefined} schoolTaxes={financials?.schoolTax ?? undefined} condoFees={financials?.condoFees ?? undefined} /></section>123        )}124125        {tab === "quartier" && (126          <section className="space-y-4">127            {l.lat != null && l.lng != null && <div className="h-72 rounded-card overflow-hidden border border-line"><MapView points={[{ uid: l.uid, lat: l.lat, lng: l.lng, price: l.price ?? 0 }]} center={[l.lat, l.lng]} zoom={14} rent={rent} /></div>}128            {score ? (129              <div>130                <h2 className="font-bold text-lg mb-2">Scores du secteur</h2>131                <div className="grid grid-cols-3 sm:grid-cols-6 gap-2">{([["Global", score.global], ["Marche", score.walk], ["Transport", score.transit], ["Vélo", score.bike], ["Calme", score.calm], ["Services", score.services]] as const).map(([k, v]) => <div key={k} className="tv-card p-3 text-center"><p className="text-2xl font-extrabold tabular-nums">{v != null ? v.toFixed(1) : "—"}</p><p className="text-xs text-ink-3">{k}</p></div>)}</div>132                <p className="mt-2 text-xs text-ink-3">Scores de quartier Toveli ({score.version}) : densité de commerces, arrêts de transport, pistes cyclables, trafic et services dans un rayon de marche, à partir d'OpenStreetMap (© contributeurs OSM, ODbL) et du recensement 2021. Calculés le {fmtDate(score.computed_at)}.</p>133              </div>134            ) : <Notice>Pas de score de quartier disponible pour ce secteur (zone non couverte par les tuiles calculées).</Notice>}135            {sales.length > 0 && (136              <div><h2 className="font-bold text-lg mb-2">Ventes récentes à proximité</h2><ul className="tv-card divide-y divide-line text-sm">{sales.map((s) => <li key={s.id} className="p-2.5 flex justify-between gap-3"><span><span className="font-medium">{s.street}</span><span className="text-ink-3 text-xs"> · {s.city}{s.property_type ? ` · ${s.property_type}` : ""}{s.floor_area ? ` · ${Math.round(s.floor_area)} m²` : ""}</span></span><span className="text-right tabular-nums shrink-0">{fmtPrice(s.amount)}<br /><span className="text-xs text-ink-3">{fmtDate(s.date)}</span></span></li>)}</ul></div>137            )}138          </section>139        )}140      </div>141    </div>142  );143}144